/**
 * @license
 * Copyright (C) 2016 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
import '@polymer/paper-input/paper-input.js';
import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown.js';
import '../gr-cursor-manager/gr-cursor-manager.js';
import '../gr-icons/gr-icons.js';
import '../../../styles/shared-styles.js';
import {flush, dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js';
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js';
import {PolymerElement} from '@polymer/polymer/polymer-element.js';
import {htmlTemplate} from './gr-autocomplete_html.js';
import {KeyboardShortcutBehavior} from '../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.js';

const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
const DEBOUNCE_WAIT_MS = 200;

/**
 * @extends PolymerElement
 */
class GrAutocomplete extends mixinBehaviors( [
  KeyboardShortcutBehavior,
], GestureEventListeners(
    LegacyElementMixin(
        PolymerElement))) {
  static get template() { return htmlTemplate; }

  static get is() { return 'gr-autocomplete'; }
  /**
   * Fired when a value is chosen.
   *
   * @event commit
   */

  /**
   * Fired when the user cancels.
   *
   * @event cancel
   */

  /**
   * Fired on keydown to allow for custom hooks into autocomplete textbox
   * behavior.
   *
   * @event input-keydown
   */

  static get properties() {
    return {

      /**
       * Query for requesting autocomplete suggestions. The function should
       * accept the input as a string parameter and return a promise. The
       * promise yields an array of suggestion objects with "name", "label",
       * "value" properties. The "name" property will be displayed in the
       * suggestion entry. The "label" property will, when specified, appear
       * next to the "name" as label text. The "value" property will be emitted
       * if that suggestion is selected.
       *
       * @type {function(string): Promise<?>}
       */
      query: {
        type: Function,
        value() {
          return function() {
            return Promise.resolve([]);
          };
        },
      },

      /**
       * The number of characters that must be typed before suggestions are
       * made. If threshold is zero, default suggestions are enabled.
       */
      threshold: {
        type: Number,
        value: 1,
      },

      allowNonSuggestedValues: Boolean,
      borderless: Boolean,
      disabled: Boolean,
      showSearchIcon: {
        type: Boolean,
        value: false,
      },
      /**
       * Vertical offset needed for an element with 20px line-height, 4px
       * padding and 1px border (30px height total). Plus 1px spacing between
       * input and dropdown. Inputs with different line-height or padding will
       * need to tweak vertical offset.
       */
      verticalOffset: {
        type: Number,
        value: 31,
      },

      text: {
        type: String,
        value: '',
        notify: true,
      },

      placeholder: String,

      clearOnCommit: {
        type: Boolean,
        value: false,
      },

      /**
       * When true, tab key autocompletes but does not fire the commit event.
       * When false, tab key not caught, and focus is removed from the element.
       * See Issue 4556, Issue 6645.
       */
      tabComplete: {
        type: Boolean,
        value: false,
      },

      value: {
        type: String,
        notify: true,
      },

      /**
       * Multi mode appends autocompleted entries to the value.
       * If false, autocompleted entries replace value.
       */
      multi: {
        type: Boolean,
        value: false,
      },

      /**
       * When true and uncommitted text is left in the autocomplete input after
       * blurring, the text will appear red.
       */
      warnUncommitted: {
        type: Boolean,
        value: false,
      },

      /**
       * When true, querying for suggestions is not debounced w/r/t keypresses
       */
      noDebounce: {
        type: Boolean,
        value: false,
      },

      /** @type {?} */
      _suggestions: {
        type: Array,
        value() { return []; },
      },

      _suggestionEls: {
        type: Array,
        value() { return []; },
      },

      _index: Number,
      _disableSuggestions: {
        type: Boolean,
        value: false,
      },
      _focused: {
        type: Boolean,
        value: false,
      },
      /**
       * Invisible label for input element. This label is exposed to
       * screen readers by paper-input
       */
      label: {
        type: String,
        value: '',
      },

      /** The DOM element of the selected suggestion. */
      _selected: Object,
    };
  }

  static get observers() {
    return [
      '_maybeOpenDropdown(_suggestions, _focused)',
      '_updateSuggestions(text, threshold, noDebounce)',
    ];
  }

  get _nativeInput() {
    // In Polymer 2 inputElement isn't nativeInput anymore
    return this.$.input.$.nativeInput || this.$.input.inputElement;
  }

  /** @override */
  attached() {
    super.attached();
    this.listen(document.body, 'click', '_handleBodyClick');
  }

  /** @override */
  detached() {
    super.detached();
    this.unlisten(document.body, 'click', '_handleBodyClick');
    this.cancelDebouncer('update-suggestions');
  }

  get focusStart() {
    return this.$.input;
  }

  focus() {
    this._nativeInput.focus();
  }

  selectAll() {
    const nativeInputElement = this._nativeInput;
    if (!this.$.input.value) { return; }
    nativeInputElement.setSelectionRange(0, this.$.input.value.length);
  }

  clear() {
    this.text = '';
  }

  _handleItemSelect(e) {
    // Let _handleKeydown deal with keyboard interaction.
    if (e.detail.trigger !== 'click') { return; }
    this._selected = e.detail.selected;
    this._commit();
  }

  get _inputElement() {
    // Polymer2: this.$ can be undefined when this is first evaluated.
    return this.$ && this.$.input;
  }

  /**
   * Set the text of the input without triggering the suggestion dropdown.
   *
   * @param {string} text The new text for the input.
   */
  setText(text) {
    this._disableSuggestions = true;
    this.text = text;
    this._disableSuggestions = false;
  }

  _onInputFocus() {
    this._focused = true;
    this._updateSuggestions(this.text, this.threshold, this.noDebounce);
    this.$.input.classList.remove('warnUncommitted');
    // Needed so that --paper-input-container-input updated style is applied.
    this.updateStyles();
  }

  _onInputBlur() {
    this.$.input.classList.toggle('warnUncommitted',
        this.warnUncommitted && this.text.length && !this._focused);
    // Needed so that --paper-input-container-input updated style is applied.
    this.updateStyles();
  }

  _updateSuggestions(text, threshold, noDebounce) {
    // Polymer 2: check for undefined
    if ([text, threshold, noDebounce].includes(undefined)) {
      return;
    }

    // Reset _suggestions for every update
    // This will also prevent from carrying over suggestions:
    // @see Issue 12039
    this._suggestions = [];

    // TODO(taoalpha): Also skip if text has not changed

    if (this._disableSuggestions) { return; }
    if (text.length < threshold) {
      this.value = '';
      return;
    }

    if (!this._focused) {
      return;
    }

    const update = () => {
      this.query(text).then(suggestions => {
        if (text !== this.text) {
          // Late response.
          return;
        }
        for (const suggestion of suggestions) {
          suggestion.text = suggestion.name;
        }
        this._suggestions = suggestions;
        flush();
        if (this._index === -1) {
          this.value = '';
        }
      });
    };

    if (noDebounce) {
      update();
    } else {
      this.debounce('update-suggestions', update, DEBOUNCE_WAIT_MS);
    }
  }

  _maybeOpenDropdown(suggestions, focused) {
    if (suggestions.length > 0 && focused) {
      return this.$.suggestions.open();
    }
    return this.$.suggestions.close();
  }

  _computeClass(borderless) {
    return borderless ? 'borderless' : '';
  }

  /**
   * _handleKeydown used for key handling in the this.$.input AND all child
   * autocomplete options.
   */
  _handleKeydown(e) {
    this._focused = true;
    switch (e.keyCode) {
      case 38: // Up
        e.preventDefault();
        this.$.suggestions.cursorUp();
        break;
      case 40: // Down
        e.preventDefault();
        this.$.suggestions.cursorDown();
        break;
      case 27: // Escape
        e.preventDefault();
        this._cancel();
        break;
      case 9: // Tab
        if (this._suggestions.length > 0 && this.tabComplete) {
          e.preventDefault();
          this._handleInputCommit(true);
          this.focus();
        } else {
          this._focused = false;
        }
        break;
      case 13: // Enter
        if (this.modifierPressed(e)) { break; }
        e.preventDefault();
        this._handleInputCommit();
        break;
      default:
        // For any normal keypress, return focus to the input to allow for
        // unbroken user input.
        this.focus();

        // Since this has been a normal keypress, the suggestions will have
        // been based on a previous input. Clear them. This prevents an
        // outdated suggestion from being used if the input keystroke is
        // immediately followed by a commit keystroke. @see Issue 8655
        this._suggestions = [];
    }
    this.dispatchEvent(new CustomEvent('input-keydown', {
      detail: {keyCode: e.keyCode, input: this.$.input},
      composed: true, bubbles: true,
    }));
  }

  _cancel() {
    if (this._suggestions.length) {
      this.set('_suggestions', []);
    } else {
      this.dispatchEvent(new CustomEvent('cancel', {
        composed: true, bubbles: true,
      }));
    }
  }

  /**
   * @param {boolean=} opt_tabComplete
   */
  _handleInputCommit(opt_tabComplete) {
    // Nothing to do if the dropdown is not open.
    if (!this.allowNonSuggestedValues &&
        this.$.suggestions.isHidden) { return; }

    this._selected = this.$.suggestions.getCursorTarget();
    this._commit(opt_tabComplete);
  }

  _updateValue(suggestion, suggestions) {
    if (!suggestion) { return; }
    const completed = suggestions[suggestion.dataset.index].value;
    if (this.multi) {
      // Append the completed text to the end of the string.
      // Allow spaces within quoted terms.
      const tokens = this.text.match(TOKENIZE_REGEX);
      tokens[tokens.length - 1] = completed;
      this.value = tokens.join(' ');
    } else {
      this.value = completed;
    }
  }

  _handleBodyClick(e) {
    const eventPath = dom(e).path;
    for (let i = 0; i < eventPath.length; i++) {
      if (eventPath[i] === this) {
        return;
      }
    }
    this._focused = false;
  }

  _handleSuggestionTap(e) {
    e.stopPropagation();
    this.$.cursor.setCursor(e.target);
    this._commit();
  }

  /**
   * Commits the suggestion, optionally firing the commit event.
   *
   * @param {boolean=} opt_silent Allows for silent committing of an
   *     autocomplete suggestion in order to handle cases like tab-to-complete
   *     without firing the commit event.
   */
  _commit(opt_silent) {
    // Allow values that are not in suggestion list iff suggestions are empty.
    if (this._suggestions.length > 0) {
      this._updateValue(this._selected, this._suggestions);
    } else {
      this.value = this.text || '';
    }

    const value = this.value;

    // Value and text are mirrors of each other in multi mode.
    if (this.multi) {
      this.setText(this.value);
    } else {
      if (!this.clearOnCommit && this._selected) {
        this.setText(this._suggestions[this._selected.dataset.index].name);
      } else {
        this.clear();
      }
    }

    this._suggestions = [];
    if (!opt_silent) {
      this.dispatchEvent(new CustomEvent('commit', {
        detail: {value},
        composed: true, bubbles: true,
      }));
    }

    this._textChangedSinceCommit = false;
  }

  _computeShowSearchIconClass(showSearchIcon) {
    return showSearchIcon ? 'showSearchIcon' : '';
  }
}

customElements.define(GrAutocomplete.is, GrAutocomplete);
